// ==UserScript==
// @name         Twitter/X 使用Aria2下载推文媒体
// @namespace    http://tampermonkey.net/
// @version      0.0.10-2025-05-10
// @description  在推文右下角添加下载按钮，点击下载推文媒体：图片（原图），gif，mp4
// @match        https://twitter.com/*
// @match        https://x.com/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=twitter.com
// @grant        GM_xmlhttpRequest
// @connect      download-x-video.com
// @connect      localhost
// @author       AgMonk
// @homepage     https://gitee.com/AgMonk/tampermonkey-script
// @downloadURL https://gitee.com/AgMonk/tampermonkey-script/raw/master/twitter_x/twitter-aria2-download.user.js
// @updateURL https://gitee.com/AgMonk/tampermonkey-script/raw/master/twitter_x/twitter-aria2-download.user.js

// ==/UserScript==

(function () {
    // 运行参数，可以 localStorage.setItem 方法填入对应KEY修改

    // rpcUrl的KEY和默认值
    const RPC_URL_KEY = "RPC_URL";
    const DEFAULT_RPC_URL = "http://localhost:6800/jsonrpc";

    // 代理服务器IP端口的KEY和默认值
    const HTTPS_PROXY_KEY = "HTTPS_PROXY";
    const DEFAULT_HTTPS_PROXY = "127.0.0.1:7890";

    // 下载目录的KEY和默认值
    const DOWNLOAD_DIR_KEY = "DOWNLOAD_DIR";
    const DEFAULT_DOWNLOAD_DIR = "d:/twitter";


    // 从localStorage中获取运行参数，如果为空则取默认值
    const getValue = (key, defaultValue) => {
        const value = localStorage.getItem(key)
        return value ? value : defaultValue
    }

    const downloadSvg = '<g xmlns="http://www.w3.org/2000/svg"><path d="M 21 15 L 20.98 18.51 C 20.98 19.89 19.86 21 18.48 21 L 5.5 21 C 4.11 21 3 19.88 3 18.5 L 3 15 L 5 15 L 5 18.5 C 5 18.78 5.22 19 5.5 19 L 18.48 19 C 18.76 19 18.98 18.78 18.98 18.5 L 19 15 L 21 15 Z M 12 16 L 17.7 10.3 L 16.29 8.88 L 13 12.18 L 13 2.59 L 11 2.59 L 11 12.18 L 7.7 8.88 L 6.29 10.3 L 12 16 Z" style=""></path></g>'
    /**
     * 向上搜索父级
     * @param child
     * @param time
     * @returns {*}
     */
    const getParent = (child, time) => {
        for (let i = 0; i < time; i++) {
            child = child.parentElement
        }
        return child
    }
    /**
     * 从指定元素开始向父级查找 a 标签，返回其链接地址
     * @param element
     * @returns {undefined|*}
     */
    const findLink = element => {
        if (element.tagName.toLocaleLowerCase() === 'a') {
            return element.href
        } else if (element.parentElement) {
            return findLink(element.parentElement)
        }
        return undefined
    }
    /**
     * 从 article 中搜索 gif 图像
     * @param article
     * @returns {string[]} 视频地址
     */
    const findGifUrl = article => [...article.querySelectorAll('video[src]')]
        // 排除引用推文
        .filter(i => getParent(i, 13).className.includes('r-18bvks7'))
        .map(i => i.src)

    /**
     *从 article 中搜索 视频封面
     * @param article
     *  @returns {string[]} 视频封面地址
     */
    const findVideoUrl = article => [...article.querySelectorAll('video:not([src])')]
        .map(i => i.poster)

    /**
     * 从 article 中搜索 静态 图像
     * @param article
     * @returns {{url: string, ext: string, index: number, id: string, author: string}[]}
     */
    const findImageUrls = article => [...article.querySelectorAll('img')]
        .filter(i => i.src.includes('/media/'))
        .map(e => {
            const [index, , id, , author] = findLink(e).split('/').reverse()
            let imageUrl = new URL(e.src);
            imageUrl.searchParams.set("name", "orig")

            return {
                url: imageUrl.toString(),
                ext: imageUrl.searchParams.get("format"),
                index: parseInt(index.toString()),
                id: id.toString(),
                author: author.toString()
            }
        })
    /**
     * 从 article 中解析推文基础信息
     * @param article
     * @returns {undefined|{url: string, path: string, user: string, id: string}}
     */
    const findArticleInfo = article => {
        const time = article.querySelector("a>time")
        if (!time) return undefined
        const url = time.parentElement.href.toString().replace('/history', '')
        const path = new URL(url).pathname.substring(1)
        const user = path.split("/")[0]
        const id = path.split("/")[2]
        return {url, path, user, id}
    }
    /**
     * 搜索未标记的推文卡片，返回其中有媒体内容的
     * @returns {*[]}
     */
    const parseArticles = () => {
        // 过滤出无 flag 标记的推文卡片
        const articles = [...document.getElementsByTagName('article')].filter(i => !i.aria2Flag)
        const res = []
        // 遍历推文卡片
        for (let article of articles) {
            // 增加 flag 标记表示已处理过
            const info = findArticleInfo(article)
            if (!info) continue
            const gif = findGifUrl(article)
            const images = findImageUrls(article)
            const videos = findVideoUrl(article)

            const count = gif.length + images.length + videos.length
            // 底部按钮栏 element
            const group = article.querySelector('[role="group"]')
            if (count > 0) {
                article.aria2Flag = true
                res.push({gif, images, info, videos, group})
            }
        }
        return res
    }

    const getDownloadDir = () => {
        const date = new Date().toLocaleDateString().replace(/\//g, '_')
        const dir = getValue(DOWNLOAD_DIR_KEY, DEFAULT_DOWNLOAD_DIR)
        return dir + "/" + date
    }

    const getProxy = () => getValue(HTTPS_PROXY_KEY, DEFAULT_HTTPS_PROXY)

    const getRpcUrl = () => getValue(RPC_URL_KEY, DEFAULT_RPC_URL)
    /**
     * 构造单个下载参数
     * @param url url
     * @param author 作者
     * @param id 推文id
     * @param index 资源序号
     * @param ext 资源扩展名
     * @returns {{methodName: string, params: [*[],{dir: string, "https-proxy": string, out: string}]}}
     */
    const buildAddUriParam = (url, author, id, index, ext) => {
        return {
            methodName: "aria2.addUri",
            params: [
                [url],
                {
                    // todo 下载目录
                    dir: getDownloadDir(),
                    "https-proxy": getProxy(),
                    "out": `${author}-${id}-${index}.${ext}`,
                },
                0
            ]
        }
    }

    /**
     * 封装POST请求方法
     * @param url 请求地址
     * @param body
     * @returns {Promise<unknown>}
     */
    async function postData(url, body) {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'POST',
                url: url,
                headers: {
                    "Content-Type": "application/json"
                },
                data: JSON.stringify(body),
                onload: function (response) {
                    try {
                        let data = JSON.parse(response.responseText);
                        resolve(data); // 请求成功，解决 Promise
                    } catch (error) {
                        reject(error); // 解析 JSON 失败，拒绝 Promise
                    }
                },
                onerror: function (error) {
                    reject(error); // 请求失败，拒绝 Promise
                }
            });
        });
    }

    /**
     * 获取视频地址的请求
     * @param path
     * @returns {Promise<*>}
     */
    async function fetchVideoUrl(path) {
        return postData("https://download-x-video.com/api/parse", {url: path})
    }

    /**
     * 提交多个下载任务
     * @param params 下载任务参数列表
     * @returns {Promise<*>}
     */
    async function addDownloadTasks(params) {
        let body = {jsonrpc: '2.0', id: new Date().getTime().toString(), method: 'system.multicall', params: [params]};
        console.log(body)
        return postData(getRpcUrl(), body)
    }


    /**
     * 下载本条推文中的媒体
     * @param article
     * @returns {Promise<void>}
     */
    async function download(article) {
        console.log(article)
        const {gif, images, info, videos, group} = article

        // 链接数量
        let count = 0

        // aria2请求的参数
        const params = []

        // 添加静态图片任务
        for (let i = 0; i < images.length; i++) {
            const image = images[i]
            params.push(buildAddUriParam(
                image.url,
                image.author,
                image.id,
                image.index,
                image.ext
            ))
            count++
        }

        // 添加 gif 任务
        for (let i = 0; i < gif.length; i++) {
            const url = new URL(gif[i])
            params.push(buildAddUriParam(
                url.toString(),
                info.user,
                info.id,
                i,
                url.href.split('.').reverse()[0]
            ))
            count++
        }

        //  添加 mp4 任务
        if (videos.length > 0) {
            const res = await fetchVideoUrl(info.path);
            const {videoInfos} = res
            const groupData = {}
            // 按前缀分组
            videoInfos.forEach((item) => {
                const key = item.quality.substring(1).split("】")[0]
                if (groupData.hasOwnProperty(key)) {
                    groupData[key].push(item)
                } else {
                    groupData[key] = [item]
                }
            })
            // 按大小排序
            Object.values(groupData).forEach(group => group.sort((a, b) => b.bitrate - a.bitrate))
            // 取每组的第一个下载地址
            const urls = Object.values(groupData).map(i => i[0].url).map(i => new URL(i))

            urls.forEach((url, index) => {
                params.push(buildAddUriParam(
                    url.toString(),
                    info.user,
                    info.id,
                    index + 1,
                    url.pathname.split('.').reverse()[0]
                ))
                count++
            })
        }
        let {result} = await addDownloadTasks(params);
        let gid = result.flatMap(i => i)
        console.log(gid)
        alert(`已提交 ${params.length} 个下载任务`)
    }

    console.log("aria2检查开始")
    // 循环执行
    setInterval(() => {
        const articles = parseArticles()
        if (articles.length > 0) console.log(articles)
        // 向 group 中添加下载按钮
        for (let article of articles) {
            const downloadDiv = document.createElement("div")
            // 复制第一个按钮的样式
            downloadDiv.innerHTML = article.group.children[1].innerHTML
            downloadDiv.className = article.group.children[1].className
            // 替换图标
            downloadDiv.querySelector('svg').innerHTML = downloadSvg
            // 图标点击事件
            downloadDiv.querySelector('button').onclick = () => download(article)
            // 移除文字
            let dir = downloadDiv.querySelector('[dir="ltr"]');
            dir.removeChild(dir.lastChild)
            // 插入末尾
            article.group.append(downloadDiv)
        }
    }, 2000)
})();
